spb/autoscholar
Public
1"use client";2import { useEffect, useRef, useState } from "react";3import { fmtDateTime } from "@/lib/format";45type Ev = {6 seq: number;7 at: string;8 level: string;9 kind: string;10 stage?: string | null;11 message: string;12 data?: unknown;13};1415export function LiveTimeline({ runId, initial, active }: { runId: string; initial: Ev[]; active: boolean }) {16 const [events, setEvents] = useState<Ev[]>(initial);17 const [state, setState] = useState<"idle" | "live" | "reconnecting" | "closed">(18 active ? "reconnecting" : "closed",19 );20 const lastSeq = useRef(initial.at(-1)?.seq ?? 0);21 const bottom = useRef<HTMLDivElement>(null);22 const [follow, setFollow] = useState(true);23 useEffect(() => {24 if (!active) return;25 let es: EventSource | null = null;26 let stopped = false;27 let retry = 1000;28 const connect = () => {29 if (stopped) return;30 es = new EventSource(`/api/admin/runs/${runId}/events?stream=1&after=${lastSeq.current}`);31 es.addEventListener("hello", () => {32 setState("live");33 retry = 1000;34 });35 es.addEventListener("run.event", (m) => {36 const ev = JSON.parse((m as MessageEvent).data) as Ev;37 if (ev.seq <= lastSeq.current) return;38 lastSeq.current = ev.seq;39 setEvents((x) => [...x, ev]);40 if (ev.kind === "status.transition" && /→ (published|failed|cancelled)/.test(ev.message)) {41 stopped = true;42 es?.close();43 setState("closed");44 setTimeout(() => window.location.reload(), 1500);45 }46 });47 es.onerror = () => {48 es?.close();49 setState("reconnecting");50 if (!stopped) {51 retry = Math.min(retry * 2, 15000);52 setTimeout(connect, retry);53 }54 };55 };56 connect();57 return () => {58 stopped = true;59 es?.close();60 };61 }, [runId, active]);62 useEffect(() => {63 if (follow) bottom.current?.scrollIntoView({ block: "nearest" });64 }, [follow]);65 return (66 <div className="surface mt-2">67 <div className="flex items-center justify-between border-b px-3 py-2 text-xs hairline">68 <span aria-live="polite">69 {state === "live" ? (70 <span style={{ color: "var(--color-ok-500)" }}>● live</span>71 ) : state === "reconnecting" ? (72 <span style={{ color: "var(--color-warn-500)" }}>● reconnecting…</span>73 ) : (74 <span className="subtle">● stream closed</span>75 )}{" "}76 · {events.length} events · cursor {lastSeq.current}77 </span>78 <label className="flex items-center gap-1">79 <input type="checkbox" checked={follow} onChange={(e) => setFollow(e.target.checked)} /> follow80 </label>81 </div>82 <ol83 className="max-h-[32rem] overflow-auto p-2 font-mono text-xs leading-relaxed"84 aria-label="Run events"85 >86 {events.length === 0 && <li className="muted p-2">No events yet.</li>}87 {events.map((e) => (88 <li89 key={e.seq}90 className="grid grid-cols-[3rem_9rem_7rem_1fr] gap-2 border-b py-1 hairline"91 style={{92 color:93 e.level === "error"94 ? "var(--color-danger-500)"95 : e.level === "warn"96 ? "var(--color-warn-500)"97 : undefined,98 }}99 >100 <span className="subtle">{e.seq}</span>101 <span className="subtle">{fmtDateTime(e.at)}</span>102 <span className="subtle truncate">{e.stage ?? ""}</span>103 <span>104 <span className="font-semibold">{e.kind}</span> {e.message}105 {e.data && Object.keys(e.data as object).length > 0 ? (106 <details className="inline">107 <summary className="inline cursor-pointer subtle"> ⋯</summary>108 <pre className="whitespace-pre-wrap">{JSON.stringify(e.data, null, 1)}</pre>109 </details>110 ) : null}111 </span>112 </li>113 ))}114 <div ref={bottom} />115 </ol>116 </div>117 );118}119